用 Rails 實作 RAG 初體驗

Posted by Anthony Chao on 2024-11-28

我第一次接觸到 RAG 是在 Hello World Dev Conference 的 workshop 中。雖然 workshop 只是簡單地帶我們實作從一個 PDF 檔案中搜尋內容,但這次體驗讓我了解到目前市場上常見解決方案背後的原理。為了進一步了解 RAG 的運作方式,我決定自己實作一個小小的 side project 試試看。

RAG 是什麼?

RAG,即 Retrieval-Augmented Generation,是一種結合檢索機制與生成模型的 AI 技術。傳統語言模型雖然功能強大,但其內部知識庫是固定的,可能會隨時間變得過時或不完整。RAG 透過兩個步驟解決這個問題:

  • 檢索:從外部知識來源(例如資料庫或文件庫)中找到相關且最新的資訊。
  • 生成:利用檢索到的資訊生成答案,確保回答準確且與當前情境相關。

這種檢索與生成的結合,使 AI 系統能產出更具時效性的答案,非常適合需要最新資訊的應用場景,例如客服支援、新聞更新或學術研究。目前,檢索步驟通常採用向量搜尋(vector search)技術來尋找相關資料。

向量搜尋是什麼?

向量資料庫使用向量表示每個物件,以保留資料的語義關係。將語意轉換成向量的過程稱為 embedding,而每個向量對應多維空間中的一個點。例如,在下圖中,香蕉的向量位置會比較靠近蘋果,而雞跟貓的向量位置會比較相近:

image

至於這些向量所在的多維空間到底有多少維度,目前最普遍被使用的 OpenAI text-embedding-3-small 模型提供 1536 維度,而進階的 text-embedding-3-large 模型則提供 3072 維度。這些高維度特性,能帶來更精確的結果。

Steps

RAG 通常由以下的步驟構成:

  • 載入:使用工具(如 langchain 的 Document loader)載入資料。
  • 分割:使用 Text Splitter 將大型文件分成小片段,以利於索引及傳入模型處理,因為大型片段難以搜尋且不適合模型的 context window。
  • 儲存:需要一個儲存和索引這些片段的地方,以便日後進行搜尋。這通常使用向量資料庫和 embedding 模型來完成。
  • 檢索:根據使用者輸入,使用 Retriever 從儲存中獲取相關片段。
  • 生成:LLM 利用問題與檢索結果生成答案。

在這次的 RAG demo 中,我使用 我的部落格 的文章作為資料來源,並採用 Rails 作為網頁框架,向量資料庫則使用 Qdrant 提供的免費雲端方案。

Ruby Code

載入

由於資料來源是 markdown 檔案,載入部分直接使用 Ruby 的 File.read(file_path) 方法。若使用更成熟的框架(如 Langchain),則可支援載入多種來源(如 PDF 或網頁)。

分割

一般來說,分割會將檔案切成多個小片段並儲存到向量資料庫,同時需維護檔案與資料庫間的關聯,方便日後更新。而為了簡化這個 side project,我只有在文章超過 token 限制時,將文章分段後使用 LLM 進行摘要,再將摘要儲存到向量資料庫中,如此一來我就不用使用另一個資料庫儲存相對應的關聯資訊。雖然這可能導致部分細節遺失,但對於這次 demo 已經足夠。

我使用的是 langchainrb 套件中的 Chunker::RecursiveText 來進行分段。順帶一提雖然 langchainrb 套件目前功能比 Python 的 Langchain 提供的功能陽春很多,但已能滿足我的大部分需求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
class AITextSummarizer
CHUNK_SIZE = 4000
CHUNK_OVERLAP_SIZE = 200
SUMMARY_TOKENS = 1000 # Desired length of summary
OPENAI_MODEL = "gpt-4o-mini"

def initialize
@client = OpenAI::Client.new(access_token: ENV["OPENAI_API_KEY"])
end

def summarize(text)
chunks = split_into_chunks(text)
summaries = chunks.map { |chunk| summarize_chunk(chunk.text) }
combine_summaries(summaries)
end

private

def split_into_chunks(text)
Langchain::Chunker::RecursiveText.new(text, chunk_size: CHUNK_SIZE, chunk_overlap: CHUNK_OVERLAP_SIZE, separators: [ "\n" ]).chunks
end

def combine_summaries(summaries)
if summaries.size == 1
summaries.first
else
combined_summary = summaries.join("\n\n")
summarize_summaries(combined_summary)
end
end

def summarize_chunk(text)
summarize_with_prompt(text, "請把下面這段文字用繁體中文總結到大約 100 字:")
end

def summarize_summaries(text)
summarize_with_prompt(text, "請把下面這段文字用繁體中文總結,盡量不要遺漏太多細節:")
end

def summarize_with_prompt(text, prompt)
response = @client.chat(
parameters: {
model: OPENAI_MODEL,
messages: [
{ role: "system", content: "You are a helpful assistant that summarizes text." },
{ role: "user", content: "#{prompt}\n\n#{text}" }
],
max_tokens: SUMMARY_TOKENS
}
)
response.dig("choices", 0, "message", "content")
end
end

儲存

儲存資料需要嵌入模型(embedding model)將資料轉換為向量,然後將結果存入向量資料庫。在我的實作中,我將文章內容、標題及網址等相關資訊一起存入向量資料庫的 payload(可以想像成 metadata),這樣最後 LLM 回答問題時就可以直接告訴我相關的網址跟文章標題。

閱讀下面的程式碼前有幾點需要先知道

  1. langchainrb 套件的 Langchain::Vectorsearch::Qdrant 會自動幫忙使用 llm 的 embedding model 轉成向量,所以只要提供使用的 llm API key 即可
  2. Qdrant 的專有名詞
    a. collection 是用來儲存 points 的集合,可以想像成是 RDBMS 裡面的 table
    b. point 是在向量資料庫的最重要 entity,一個點包含了一個向量跟 payload,可以想像成是 RDBMS 裡面的 row
  3. 使用之後才發現 Qdrant 中 point 的 id 必須遵守幾種可能的格式(ref),我這裡使用文章的發布日期轉成 uuid 作為 point 的 id,如此一來同一篇文章更新的時候,同樣的發布日期就可以直接取代舊的 point
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
add_point(file_name: file_name, original_content: content, extracted_content: extracted_content)

def add_point(file_name:, original_content:, extracted_content:)
Langchain::VectorSearchClient.new(collection_name: COLLECTION_NAME).add_point(
content: extracted_content,
id: uuid_for(original_content),
payload: {
title: title_for(original_content),
url: url_for(file_name)
}
)
end

# 目前用建立時間作為 uuid 根據
def uuid_for(content)
datetime_str = content.match(/^date:\s*(.*)/)[1]
brief_datetime_str = DateTime.parse(datetime_str)
.strftime("%Y%m%d%H%M%S")

sha1_hash = Digest::SHA1.hexdigest(brief_datetime_str)
# to make sha1 match uuid format
# https://qdrant.tech/documentation/concepts/points/
"#{sha1_hash[0..7]}-#{sha1_hash[8..11]}-#{sha1_hash[12..15]}-#{sha1_hash[16..19]}-#{sha1_hash[20..31]}"
end

module Langchain
class VectorSearchClient
def initialize(collection_name:)
# default openai model: text-embedding-3-small
@qdrant = Langchain::Vectorsearch::Qdrant.new(
url: ENV["QDRANT_URL"],
api_key: ENV["QDRANT_API_KEY"],
index_name: collection_name, # Note: collection name 近似於 RDBMS 的 table name,用來儲存 points
llm: Langchain::LLM::OpenAI.new(api_key: ENV["OPENAI_API_KEY"])
)
end

def add_point(content:, id:, payload: {})
@qdrant.add_texts(
texts: [ content ],
ids: [ id ],
payload: payload
)
end
end
end

檢索 & 生成

langchainrb 套件的 Langchain::Vectorsearch::Qdrant 模組會自動使用 LLM 的 embedding model 把問題轉成向量並進行相似度搜尋。搜尋結果返回後,LLM 將根據問題和檢索資料生成答案。

以下程式碼中的 k 表示從相似度搜尋中返回的 point 數量,LLM 會以這些 point 的資料作為生成答案的基礎。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class QuestionsController < ApplicationController
RELATED_POSTS_AMOUNT = 3

def create
@client = Langchain::VectorSearchClient.new(collection_name: "blog")
@response = @client.ask(question: question_params, k: RELATED_POSTS_AMOUNT).completion
respond_to do |format|
format.turbo_stream
end
end
end

module Langchain
class VectorSearchClient
# ...
def ask(question:, k:)
@qdrant.ask(question: question, k: k)
end
# ...
end
end

Demo

以上就是這個小專案中跟 RAG 有關的部分。

在 UI 實作方面,,我使用 Rails 搭配 Turbo 來作出簡單的問答頁面,成果長得像下面這樣:

問問題的頁面:

image

LLM 回答的結果:

image

從上面的圖片可以看到因為我有給他文章網址,因此他在回答的時候也能提供這部分的資訊。

心得

實作這個小專案的過程中,我才真正體會到開發一個 RAG 應用程式時需要注意的各種細節。例如,當一篇文章被更新時,如何有效地同步更新向量資料庫中的相關資料,以及針對長篇文章,有哪些分割方法可以使用,而哪些方法的效果大家實驗後覺得較為理想,這些都是值得研究的議題。

接下來,我計劃進一步學習 LangChain 的使用方式,透過實作幾個小應用來熟悉他。接著希望能深入了解實作細節和所使用的 prompt 等等,可能未來能更靈活地應用到其他專案中。

References

向量搜尋介紹
LangChain document





prevent_hack